<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Form\Extension\Core\DataTransformer;

use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Exception\UnexpectedTypeException;

/**
 * Transforms between a date string and a DateTime object.
 *
 * @author Bernhard Schussek <bschussek@gmail.com>
 * @author Florian Eckerstorfer <florian@eckerstorfer.org>
 */
class DateTimeToStringTransformer extends BaseDateTimeTransformer
{
    /**
     * Format used for generating strings.
     *
     * @var string
     */
    private $generateFormat;

    /**
     * Format used for parsing strings.
     *
     * Different than the {@link $generateFormat} because formats for parsing
     * support additional characters in PHP that are not supported for
     * generating strings.
     *
     * @var string
     */
    private $parseFormat;

    /**
     * Whether to parse by appending a pipe "|" to the parse format.
     *
     * This only works as of PHP 5.3.7.
     *
     * @var bool
     */
    private $parseUsingPipe;

    /**
     * Transforms a \DateTime instance to a string.
     *
     * @see \DateTime::format() for supported formats
     *
     * @param string $inputTimezone  The name of the input timezone
     * @param string $outputTimezone The name of the output timezone
     * @param string $format         The date format
     * @param bool   $parseUsingPipe Whether to parse by appending a pipe "|" to the parse format
     *
     * @throws UnexpectedTypeException if a timezone is not a string
     */
    public function __construct($inputTimezone = null, $outputTimezone = null, $format = 'Y-m-d H:i:s', $parseUsingPipe = true)
    {
        parent::__construct($inputTimezone, $outputTimezone);

        $this->generateFormat = $this->parseFormat = $format;

        $this->parseUsingPipe = $parseUsingPipe || null === $parseUsingPipe;

        // See http://php.net/manual/en/datetime.createfromformat.php
        // The character "|" in the format makes sure that the parts of a date
        // that are *not* specified in the format are reset to the corresponding
        // values from 1970-01-01 00:00:00 instead of the current time.
        // Without "|" and "Y-m-d", "2010-02-03" becomes "2010-02-03 12:32:47",
        // where the time corresponds to the current server time.
        // With "|" and "Y-m-d", "2010-02-03" becomes "2010-02-03 00:00:00",
        // which is at least deterministic and thus used here.
        if ($this->parseUsingPipe && false === strpos($this->parseFormat, '|')) {
            $this->parseFormat .= '|';
        }
    }

    /**
     * Transforms a DateTime object into a date string with the configured format
     * and timezone.
     *
     * @param \DateTime|\DateTimeInterface $value A DateTime object
     *
     * @return string A value as produced by PHP's date() function
     *
     * @throws TransformationFailedException If the given value is not a \DateTime
     *                                       instance or if the output timezone
     *                                       is not supported.
     */
    public function transform($value)
    {
        if (null === $value) {
            return '';
        }

        if (!$value instanceof \DateTime && !$value instanceof \DateTimeInterface) {
            throw new TransformationFailedException('Expected a \DateTime or \DateTimeInterface.');
        }

        if (!$value instanceof \DateTimeImmutable) {
            $value = clone $value;
        }

        try {
            $value = $value->setTimezone(new \DateTimeZone($this->outputTimezone));
        } catch (\Exception $e) {
            throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e);
        }

        return $value->format($this->generateFormat);
    }

    /**
     * Transforms a date string in the configured timezone into a DateTime object.
     *
     * @param string $value A value as produced by PHP's date() function
     *
     * @return \DateTime An instance of \DateTime
     *
     * @throws TransformationFailedException If the given value is not a string,
     *                                       if the date could not be parsed or
     *                                       if the input timezone is not supported.
     */
    public function reverseTransform($value)
    {
        if (empty($value)) {
            return;
        }

        if (!is_string($value)) {
            throw new TransformationFailedException('Expected a string.');
        }

        $outputTz = new \DateTimeZone($this->outputTimezone);
        $dateTime = \DateTime::createFromFormat($this->parseFormat, $value, $outputTz);

        $lastErrors = \DateTime::getLastErrors();

        if (0 < $lastErrors['warning_count'] || 0 < $lastErrors['error_count']) {
            throw new TransformationFailedException(
                implode(', ', array_merge(
                    array_values($lastErrors['warnings']),
                    array_values($lastErrors['errors'])
                ))
            );
        }

        try {
            // On PHP versions < 5.3.7 we need to emulate the pipe operator
            // and reset parts not given in the format to their equivalent
            // of the UNIX base timestamp.
            if (!$this->parseUsingPipe) {
                list($year, $month, $day, $hour, $minute, $second) = explode('-', $dateTime->format('Y-m-d-H-i-s'));

                // Check which of the date parts are present in the pattern
                preg_match(
                    '/('.
                    '(?P<day>[djDl])|'.
                    '(?P<month>[FMmn])|'.
                    '(?P<year>[Yy])|'.
                    '(?P<hour>[ghGH])|'.
                    '(?P<minute>i)|'.
                    '(?P<second>s)|'.
                    '(?P<dayofyear>z)|'.
                    '(?P<timestamp>U)|'.
                    '[^djDlFMmnYyghGHiszU]'.
                    ')*/',
                    $this->parseFormat,
                    $matches
                );

                // preg_match() does not guarantee to set all indices, so
                // set them unless given
                $matches = array_merge(array(
                    'day' => false,
                    'month' => false,
                    'year' => false,
                    'hour' => false,
                    'minute' => false,
                    'second' => false,
                    'dayofyear' => false,
                    'timestamp' => false,
                ), $matches);

                // Reset all parts that don't exist in the format to the
                // corresponding part of the UNIX base timestamp
                if (!$matches['timestamp']) {
                    if (!$matches['dayofyear']) {
                        if (!$matches['day']) {
                            $day = 1;
                        }
                        if (!$matches['month']) {
                            $month = 1;
                        }
                    }
                    if (!$matches['year']) {
                        $year = 1970;
                    }
                    if (!$matches['hour']) {
                        $hour = 0;
                    }
                    if (!$matches['minute']) {
                        $minute = 0;
                    }
                    if (!$matches['second']) {
                        $second = 0;
                    }
                    $dateTime->setDate($year, $month, $day);
                    $dateTime->setTime($hour, $minute, $second);
                }
            }

            if ($this->inputTimezone !== $this->outputTimezone) {
                $dateTime->setTimezone(new \DateTimeZone($this->inputTimezone));
            }
        } catch (\Exception $e) {
            throw new TransformationFailedException($e->getMessage(), $e->getCode(), $e);
        }

        return $dateTime;
    }
}
